Skip to content

feat: added Web Push API support (#3845)#3950

Merged
thorsten merged 7 commits intomainfrom
feat/web-push-api
Feb 6, 2026
Merged

feat: added Web Push API support (#3845)#3950
thorsten merged 7 commits intomainfrom
feat/web-push-api

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 6, 2026

Summary by CodeRabbit

  • New Features

    • Web Push: opt-in banner, UCP toggle, admin Push tab with VAPID key generation, service worker, and automatic browser notifications for new FAQs/questions.
  • Infrastructure

    • Service worker served with JS content-type, security and no-cache headers; new push subscriptions storage, DB schema and migrations added; backend APIs to manage subscriptions.
  • Localization

    • English and German push strings and configuration entries added (duplicate entries present).
  • Tests / Quality

    • New tests for push API, repository, service, and frontend flows.
  • Chores

    • PHP extension and dependency adjustments; changelog updated.

@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Adds end-to-end Web Push: service worker, backend WebPush service and repository, frontend and admin APIs and UI, DB table and migration, DI wiring, VAPID key generation, client subscription flows, translations, tests, and nginx/ESLint edits to serve the service worker.

Changes

Cohort / File(s) Summary
Nginx / Docker config
\.docker/nginx/default.conf, nginx.conf
Add explicit location ^~ /sw.js to serve the service worker with Content-Type: application/javascript and headers Service-Worker-Allowed: "/", Cache-Control: no-cache, no-store, must-revalidate, X-Content-Type-Options: nosniff.
Composer / PHP extensions
composer.json, phpmyfaq/src/phpMyFAQ/System.php
Add minishlink/web-push dependency and require ext-mbstring and ext-openssl; update System required extensions.
ESLint / dev tooling
eslint.config.mjs
Add TypeScript no-unused-vars rule ignoring leading _ and add file-scoped readonly global self for phpmyfaq/sw.js.
DI / services
phpmyfaq/src/services.php
Register PushSubscriptionRepository and WebPushService services and inject WebPushService into notification service.
Schema, migration & seeder
phpmyfaq/src/.../DatabaseSchema.php, phpmyfaq/src/.../Migration420Alpha.php, phpmyfaq/src/.../DefaultDataSeeder.php
Add faqpush_subscriptions table schema, dialect-specific migration SQL, and default config keys (push.enableWebPush, push.vapidPublicKey, push.vapidPrivateKey, push.vapidSubject).
Entity & repository
phpmyfaq/src/phpMyFAQ/Entity/PushSubscriptionEntity.php, phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php
Add PushSubscriptionEntity with typed properties/getters/setters; add PushSubscriptionRepository with save (upsert), delete, query, hasSubscription, and row mapping logic.
WebPush service
phpmyfaq/src/phpMyFAQ/Push/WebPushService.php
New readonly WebPushService: enablement checks, VAPID key access/generation, sendToAll/sendToUser/sendToUsers, payload building, queue/flush, expired-endpoint cleanup, and logging.
Admin controllers & FAQ integration
phpmyfaq/src/.../Administration/Api/PushController.php, phpmyfaq/src/.../Administration/Api/FaqController.php
Add admin API POST /push/generate-vapid-keys (CSRF + CONFIGURATION_EDIT) to generate/store VAPID keys; FAQ creation triggers WebPushService::sendToAll for new active FAQs (errors logged).
Frontend controllers (API)
phpmyfaq/src/.../Frontend/Api/PushController.php
Add frontend endpoints: GET /push/vapid-public-key, POST /push/subscribe, POST /push/unsubscribe, GET /push/status — handle subscription persistence, deletion, and status checks (auth where required).
Notification + small refactors
phpmyfaq/src/phpMyFAQ/Notification.php, phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php, phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php
Wire optional WebPushService into Notification; send web push for new questions; use DI for Notification in QuestionController; expose pushEnabled flag to templates.
Frontend JS — client API & orchestrator
phpmyfaq/assets/src/api/push.ts, phpmyfaq/assets/src/push/index.ts, phpmyfaq/assets/src/frontend.ts, phpmyfaq/assets/src/api/push.test.ts
Add client API for vapid key, subscribe/unsubscribe/status; orchestrator registers SW, manages subscription flows, banner & UCP toggle UI, toasts, localStorage persistence; tests added.
Admin JS — VAPID generation UI
phpmyfaq/admin/assets/src/api/push.ts, phpmyfaq/admin/assets/src/api/push.test.ts, phpmyfaq/admin/assets/src/configuration/webpush.ts, phpmyfaq/admin/assets/src/configuration/configuration.ts, phpmyfaq/admin/assets/src/configuration/index.ts
Add handleWebPush admin UI handler to generate VAPID keys, mask private key, remove VAPID fields from form submission, add Generate button, and show notifications; API client and tests included; re-export added.
Templates / UI
phpmyfaq/assets/templates/default/index.twig, phpmyfaq/assets/templates/default/ucp.twig, phpmyfaq/assets/templates/admin/configuration/main.twig
Add push opt-in banner (conditional on pushEnabled and logged-in), UCP toggle card(s) guarded by pushEnabled, and a Push tab in admin configuration.
Service worker
phpmyfaq/sw.js
New service worker handling install/activate, push event (JSON/text fallback), notification display, and notificationclick to focus or open clients.
Translations
phpmyfaq/translations/language_en.php, phpmyfaq/translations/language_de.php
Add configuration entries and language strings for push notifications; note: blocks were inserted twice in both files (duplicate entries present).
Tests & expectations
tests/.../PushSubscriptionEntityTest.php, tests/.../Push/PushSubscriptionRepositoryTest.php, tests/.../Push/WebPushServiceTest.php, tests/phpMyFAQ/Faq/PermissionTest.php, tests/.../Setup/...
Add unit tests for entity, repository, and service; update permission tests to expect pre-insert existence checks (extra SELECTs); adjust expected table count and migration-version test.
Service worker serving / linting / misc
\.docker/nginx/default.conf, nginx.conf, eslint.config.mjs, CHANGELOG.md
Nginx/ESLint updates to serve & lint the service worker; changelog entry added for push feature.

Sequence Diagrams

sequenceDiagram
    participant Browser as Browser/User
    participant Frontend as Frontend App
    participant SW as Service Worker
    participant API as Backend API
    participant DB as Database

    Browser->>Frontend: page load (DOMContentLoaded)
    Frontend->>API: GET /push/vapid-public-key
    API-->>Frontend: { enabled, vapidPublicKey }
    Frontend->>SW: register('/sw.js')
    Frontend->>Browser: request Notification permission
    Browser-->>Frontend: permission granted
    Frontend->>SW: PushManager.subscribe()
    SW-->>Frontend: subscription object
    Frontend->>API: POST /push/subscribe (subscription)
    API->>DB: save subscription
    DB-->>API: OK
    API-->>Frontend: { success: true }
Loading
sequenceDiagram
    participant Admin as Admin User
    participant AdminUI as Admin UI
    participant AdminAPI as Admin API
    participant Config as Configuration Store
    participant WebPush as WebPushService
    participant Subs as Subscribers

    Admin->>AdminUI: click "Generate VAPID Keys"
    AdminUI->>AdminAPI: POST /admin/api/push/generate-vapid-keys
    AdminAPI->>WebPush: generateVapidKeys()
    WebPush-->>AdminAPI: { publicKey, privateKey }
    AdminAPI->>Config: store keys and subject
    Config-->>AdminAPI: OK
    AdminAPI-->>AdminUI: { success, publicKey }
    Note over WebPush,Subs: Later, when new active FAQ is created
    WebPush->>Subs: send notifications to endpoints
    Subs->>SW: push events delivered
    SW->>Browser: show notifications
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇
I hopped through files both near and far,
stitched keys and workers, caught each spar.
Subscriptions planted, bell prepares to ring,
a tiny carrot note for every ping. 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: added Web Push API support' clearly and concisely describes the main feature addition throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/web-push-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/phpMyFAQ/Faq/PermissionTest.php (1)

58-110: ⚠️ Potential issue | 🟡 Minor

Missing test for the "permission already exists" (skip INSERT) path.

The core new behavior added in Permission::add() is skipping the INSERT when the permission already exists. None of the updated tests cover this branch — all tests mock numRows to return 0. Add a test where numRows returns > 0 and verify that only the SELECT query is issued (no INSERT).

Suggested test
public function testAddSkipsInsertWhenPermissionAlreadyExists(): void
{
    $resultMock = $this->createMock(\SQLite3Result::class);

    // Only 1 query expected: the SELECT check (INSERT should be skipped)
    $this->dbMock
        ->expects($this->once())
        ->method('query')
        ->with($this->stringContains('SELECT 1'))
        ->willReturn($resultMock);

    $this->dbMock
        ->expects($this->once())
        ->method('numRows')
        ->willReturn(1); // Permission already exists

    $result = $this->permission->add(Permission::USER, 123, [456]);
    $this->assertTrue($result);
}
phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php (1)

38-41: ⚠️ Potential issue | 🟡 Minor

Migration description is stale — doesn't mention push subscriptions.

getDescription() lists "Admin log hash columns, custom pages, chat messages, translation config" but the migration now also creates the push subscriptions table and adds web push config entries.

Suggested update
     public function getDescription(): string
     {
-        return 'Admin log hash columns, custom pages, chat messages, translation config';
+        return 'Admin log hash columns, custom pages, chat messages, translation config, push subscriptions';
     }
🤖 Fix all issues with AI agents
In `@composer.json`:
- Line 33: Update the composer.json require section so it explicitly lists the
PHP extensions required by minishlink/web-push v9.0: add "ext-mbstring" and move
"ext-openssl" from suggest into require (leave "ext-json" as-is since it is
already required); keep ext-gmp and ext-bcmath only as optional (suggest) if
present. Ensure the composer.json "require" object contains "ext-mbstring" and
"ext-openssl" alongside existing PHP and ext-json entries.

In `@phpmyfaq/admin/assets/src/api/push.ts`:
- Around line 24-33: The fetchGenerateVapidKeys function must accept a csrfToken
parameter and include it in the POST body/headers like other admin API calls;
update the function signature fetchGenerateVapidKeys(csrfToken: string) and add
the token to the fetch request (consistent with deleteAttachments/deleteCategory
usage) so the client sends the CSRF token, and update the server-side
PushController::generateVapidKeys() to call
Token::getInstance($this->session)->verifyToken() to validate the token before
generating/storing VAPID keys.

In `@phpmyfaq/assets/src/push/index.ts`:
- Around line 237-238: The service worker registration uses a hardcoded root
path (navigator.serviceWorker.register('/sw.js')) which will 404 for installs
under a subdirectory; change phpmyfaq/assets/src/push/index.ts to compute the SW
URL from the app base (e.g., read a <base href="..."> or a meta tag you add for
the app root) and then call navigator.serviceWorker.register(swUrl) instead of
'/sw.js'. Use a robust URL join (e.g., new URL('sw.js', baseHref) or equivalent)
so the created swUrl works whether the app is at domain root or a subdirectory;
update the const registration assignment to use that computed swUrl.
- Around line 45-61: The showToast function currently injects the untrusted
message string via innerHTML causing XSS; change it to build the alert DOM
programmatically: create the alert div (alert.className, role), append a text
node or set textContent for the message instead of interpolating into innerHTML,
create the close button element (set type, classList, data-bs-dismiss and
aria-label) and append it to the alert, then append the alert to the container
and keep the existing timeout logic that removes the show class and then the
element; update the function name showToast to use textContent/createTextNode
for message and avoid any innerHTML usage.

In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php`:
- Around line 262-280: Duplicate web-push notifications are caused by both
FaqController calling sendToAll(...) and Notification::sendNewFaqAdded() calling
sendWebPush(); pick one call site to remove: if you want end-users to receive
the public FAQ URL, remove the sendWebPush() call inside
Notification::sendNewFaqAdded() (which currently builds the admin edit URL);
otherwise, if you want admins to receive the admin URL via Notification, delete
the direct sendToAll(...) block in FaqController (the block that builds $faqUrl
with TitleSlugifier::slug(...) and calls $webPushService->sendToAll(...));
ensure the remaining code still triggers exactly one push and update any tests
or comments accordingly.
- Around line 262-280: Wrap the WebPushService lookup and sendToAll call in a
try/catch similar to the email notification block to prevent exceptions from
aborting the response: around the
container->get('phpmyfaq.push.web-push-service') and
$webPushService->sendToAll(...) in the FaqController, catch Throwable/Exception,
log the error (using the same logger used for emails) and continue so the
success response is still returned; ensure the catch references the exception
when logging to aid debugging.

In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php`:
- Around line 34-53: Add CSRF verification and robust error handling to
generateVapidKeys(): before proceeding, perform the same CSRF token check used
by other admin POST endpoints (see FaqController::create) and return a JSON
error if the token is invalid; wrap the calls to
WebPushService::generateVapidKeys() and $this->configuration->update(...) in a
try/catch (catch \Throwable) and on error return $this->json(['success' =>
false, 'message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
and fix the subject check to handle null by using
($this->configuration->get('push.vapidSubject') ?? '') === '' so missing keys
are treated like empty strings. Ensure success still returns $this->json([...],
Response::HTTP_OK) as before.

In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php`:
- Around line 120-125: The pushEnabled expression in AbstractFrontController
uses $this->configuration->get('push.vapidPublicKey') !== '' which treats null
as not equal to '' and can incorrectly enable push; update the check for the
'push.vapidPublicKey' value retrieved via Configuration::get() to be defensive
(e.g., ensure it's not null and not an empty string or use a non-empty-string
check) when computing the 'pushEnabled' value so push is only enabled when a
valid VAPID key exists.

In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php`:
- Around line 37-47: getVapidPublicKey is currently unauthenticated but uses a
private route name; update the Route attribute on the getVapidPublicKey() method
to follow frontend naming conventions by replacing
"api.private.push.vapid-public-key" with "public.push.vapid-public-key" (or if
you decide it must be private instead, add a call to
$this->userIsAuthenticated() at the start of getVapidPublicKey() and keep the
existing name), ensuring the route name change is reflected wherever this route
is referenced so naming is consistent with other push endpoints.
- Around line 55-87: The json_decode calls in PushController::subscribe and
PushController::unsubscribe use JSON_THROW_ON_ERROR and can throw \JsonException
which currently bubbles to a 500; wrap each json_decode in a try/catch that
catches \JsonException and returns $this->json(['error' => 'Invalid JSON
payload'], Response::HTTP_BAD_REQUEST) so malformed input yields a 400, leaving
the rest of the existing validation and repository logic unchanged (update the
error handling around the Request $request parsing in both subscribe and
unsubscribe methods).

In `@phpmyfaq/src/phpMyFAQ/Faq/Permission.php`:
- Around line 69-72: The code calls
$this->configuration->getDb()->query($checkQuery) and then immediately passes
the result to $this->configuration->getDb()->numRows(...), which can throw if
query() returns false; update the Permission class to check the query result
before using it: after calling $this->configuration->getDb()->query($checkQuery)
verify the return is not false (or is a valid result resource/object), log or
handle the DB error and skip/continue if the check failed, then only call
numRows when safe; apply the same defensive check pattern to the subsequent
INSERT query call (the code that constructs and executes the INSERT) to avoid
passing false into other DB helper methods.

In `@phpmyfaq/src/phpMyFAQ/Notification.php`:
- Around line 135-146: The current Notification::sendWebPush call builds and
sends an admin-only URL ('admin/faq/edit/{id}/{lang}') to all subscribers via
sendWebPush/sendToAll, which causes non-admin users to receive an unusable link
and duplicates the public push sent by FaqController; update Notification to
target only admins (use WebPushService::sendToUser or an admin-filtered
audience) or replace the href with the public FAQ URL used in FaqController so
only appropriate recipients get the admin edit link; locate the sendWebPush
invocation in Notification and either switch to
WebPushService::sendToUser/sendToAdmins (or equivalent audience filter) or
construct the public-facing URL before sending.

In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 46-49: The DB query result isn't checked for false before calling
$db->fetchObject, which can cause a TypeError; in PushSubscriptionRepository
update each method that does "$result = $db->query($query); $existing =
$db->fetchObject($result);" (e.g., the methods handling endpoint lookup,
getByUserId, getAll, and hasSubscription) to first verify $result !== false and
handle the error by returning the appropriate empty value (null for
single-object getters, [] for getAll, false for hasSubscription) or
logging/throwing as the repository's error policy dictates; ensure the check
happens immediately after $db->query(...) and before any call to
$db->fetchObject(...) or $db->fetchObjectAll(...).

In `@phpmyfaq/src/phpMyFAQ/Push/WebPushService.php`:
- Around line 39-50: The isEnabled() method incorrectly treats null VAPID keys
as present because it uses strict !== '' checks; update isEnabled() (in
WebPushService::isEnabled) to treat unset/null/empty values as absent by using
empty() (or equivalent) for 'push.vapidPublicKey' and 'push.vapidPrivateKey'
checks, and also normalize/guard the 'push.enableWebPush' value (currently
$enableWebPush) to handle null in the boolean/string check so the method only
returns true when enableWebPush is truthy and both VAPID keys are non-empty.

In `@phpmyfaq/sw.js`:
- Around line 53-70: The notificationclick handler currently compares client.url
to a possibly relative event.notification.data?.url (default '/'), which never
matches; resolve the payload URL against the service worker origin before
comparison and before opening: create a resolvedUrl = new
URL(event.notification.data?.url || '/', self.location.origin) and compare
client.url to resolvedUrl.href (and pass resolvedUrl.href to
self.clients.openWindow). Update the code in the notificationclick listener
where client.url and self.clients.openWindow are used (the event handler
anonymous function) to use the resolved absolute URL.
🧹 Nitpick comments (19)
phpmyfaq/src/phpMyFAQ/Faq/Permission.php (1)

58-84: Consider reducing query count for large ID arrays.

The current approach issues 2 queries per ID (SELECT + INSERT). For bulk operations (e.g., 100 IDs → 200 queries), this can be costly. A single SELECT to fetch all existing (record_id, {mode}_id) pairs for the given $faqId upfront, followed by INSERTs only for missing IDs, would cut the query count significantly.

Sketch: batch the existence check
     foreach ($ids as $id) {
-        // Check if permission already exists to avoid duplicate key errors
-        $checkQuery = sprintf(
-            'SELECT 1 FROM %sfaqdata_%s WHERE record_id = %d AND %s_id = %d',
-            Database::getTablePrefix(),
-            $mode,
-            $faqId,
-            $mode,
-            $id,
-        );
-
-        $result = $this->configuration->getDb()->query($checkQuery);
-        if ($this->configuration->getDb()->numRows($result) > 0) {
-            continue; // Permission already exists, skip
-        }
-
         $query = sprintf(
             'INSERT INTO %sfaqdata_%s (record_id, %s_id) VALUES (%d, %d)',
             Database::getTablePrefix(),

Replace with a single upfront query:

// Fetch existing IDs in one query
$existingIds = [];
$checkQuery = sprintf(
    'SELECT %s_id FROM %sfaqdata_%s WHERE record_id = %d',
    $mode,
    Database::getTablePrefix(),
    $mode,
    $faqId,
);
$result = $this->configuration->getDb()->query($checkQuery);
if ($result && $this->configuration->getDb()->numRows($result) > 0) {
    while ($row = $this->configuration->getDb()->fetchObject($result)) {
        $existingIds[] = (int) $row->{$mode . '_id'};
    }
}

foreach ($ids as $id) {
    if (in_array($id, $existingIds, true)) {
        continue;
    }
    // ... INSERT ...
}
tests/phpMyFAQ/Entity/PushSubscriptionEntityTest.php (1)

112-132: testFluentInterface omits assertions for endpointHash and createdAt.

The test chains all setters but only asserts a subset of fields. Consider adding the missing assertions for completeness:

💡 Proposed addition
         $this->assertEquals('authtoken', $this->entity->getAuthToken());
         $this->assertEquals('aesgcm', $this->entity->getContentEncoding());
+        $this->assertEquals(hash('sha256', 'https://example.com/push'), $this->entity->getEndpointHash());
+        $this->assertEquals($date, $this->entity->getCreatedAt());
     }
nginx.conf (1)

82-89: Consider using default_type instead of add_header Content-Type.

The Docker nginx config (.docker/nginx/default.conf) uses default_type application/javascript for the same /sw.js block, which is the correct approach. Using add_header Content-Type can result in a duplicate Content-Type header alongside the one nginx derives from its MIME type mapping, since add_header appends response headers rather than replacing core ones.

Also note that add_header in a location block suppresses all inherited add_header directives (e.g., the X-Frame-Options SAMEORIGIN on Line 73). This is fine for a service worker script but worth being aware of.

Align with the Docker nginx config
     # Service Worker - no caching, proper headers
     location = /sw.js {
-        add_header Content-Type "application/javascript; charset=utf-8";
+        default_type application/javascript;
         add_header Service-Worker-Allowed "/";
         add_header Cache-Control "no-cache, no-store, must-revalidate";
         add_header X-Content-Type-Options "nosniff";
         try_files $uri =404;
     }
phpmyfaq/admin/assets/src/api/push.test.ts (1)

12-17: vi.restoreAllMocks() won't restore globalThis.fetch after direct assignment.

Directly assigning globalThis.fetch = vi.fn(...) means restoreAllMocks() on Line 6 cannot restore the original reference — it only restores spies created via vi.spyOn. Consider using vi.spyOn instead, or save/restore the original in beforeEach/afterEach.

Use vi.spyOn for proper restoration
-      globalThis.fetch = vi.fn(() =>
-        Promise.resolve({
-          ok: true,
-          json: () => Promise.resolve(mockResponse),
-        } as Response)
-      );
+      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve(mockResponse),
+      } as Response);

And similarly for the error test:

-      const mockError = new Error('Fetch failed');
-      globalThis.fetch = vi.fn(() => Promise.reject(mockError));
+      const mockError = new Error('Fetch failed');
+      vi.spyOn(globalThis, 'fetch').mockRejectedValue(mockError);
tests/phpMyFAQ/Setup/Installation/DatabaseSchemaTest.php (1)

39-51: Consider asserting the new table name in testGetTableNamesReturnsCorrectNames.

The test verifies specific table names like faqchat_messages and faqcustompages but doesn't include faqpush_subscriptions. Adding it would keep coverage consistent with the other recently added tables.

Suggested addition
         $this->assertContains('faqchat_messages', $names);
         $this->assertContains('faqcustompages', $names);
         $this->assertContains('faqseo', $names);
+        $this->assertContains('faqpush_subscriptions', $names);
phpmyfaq/assets/templates/default/index.twig (1)

231-248: Push opt-in banner looks well-structured.

Good use of d-none default with progressive JS enhancement. Gating on both pushEnabled and isUserLoggedIn is correct — push subscriptions should only be offered to authenticated users.

One minor note: the inline z-index: 1050 matches Bootstrap's modal layer. If a modal opens while the banner is visible, they could visually overlap. Consider using z-index: 1040 (below modals) or a utility class to avoid potential stacking conflicts.

phpmyfaq/assets/src/api/push.test.ts (1)

1-4: subscribePush is not covered by any test.

The production module exports subscribePush (per the AI summary), but it is neither imported nor tested here. Consider adding a test for the subscribe flow — it's the most important endpoint in the push lifecycle.

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php (1)

33-33: Route action name uses hyphens instead of dots.

Other admin API routes use dot-separated names (e.g., admin.api.faq.create, admin.api.faqs.sticky.order). This route uses admin.api.push.generate-vapid-keys with hyphens in the action segment. Consider admin.api.push.generateVapidKeys or similar for consistency.

Based on learnings: "Admin API routes should follow the naming convention: admin.api.{resource}.{action}"

tests/phpMyFAQ/Push/PushSubscriptionRepositoryTest.php (1)

70-98: Consider adding tests with actual subscription results.

getByUserId and getAll are only tested for the empty-array case. The mapRowToEntity mapping logic (which hydrates PushSubscriptionEntity from a DB row) is never exercised. A test with fetchObject returning a mock row object would catch mapping bugs.

phpmyfaq/admin/assets/src/configuration/webpush.ts (1)

62-67: Static analysis innerHTML warnings are false positives here — all values are hardcoded string literals with no user-controlled input. No XSS risk.

However, the button label and notification messages (Lines 66, 73, 85, 87, 90, 93) are English-only hardcoded strings. If the admin interface supports localization, these should go through the translation system.

phpmyfaq/src/phpMyFAQ/Entity/PushSubscriptionEntity.php (1)

26-40: Properties are public despite having getters/setters.

All properties are declared public while the class provides getters and setters. This allows bypassing the setter API (e.g., $entity->authToken = 'x' skips the #[\SensitiveParameter] annotation context). Consider using private or protected visibility to enforce use of the setter API, especially for sensitive fields like authToken.

♻️ Suggested visibility change
 class PushSubscriptionEntity
 {
-    public int $id;
+    private int $id;
 
-    public int $userId;
+    private int $userId;
 
-    public string $endpoint;
+    private string $endpoint;
 
-    public string $endpointHash;
+    private string $endpointHash;
 
-    public string $publicKey;
+    private string $publicKey;
 
-    public string $authToken;
+    private string $authToken;
 
-    public ?string $contentEncoding;
+    private ?string $contentEncoding;
 
-    public DateTimeImmutable $createdAt;
+    private DateTimeImmutable $createdAt;
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (1)

70-77: Redundant endpointHash computation — repository recomputes it.

The entity's endpointHash is set here (Line 74), but PushSubscriptionRepository::save() (see PushSubscriptionRepository.php Line 43) recomputes it from the endpoint, ignoring the entity's hash. This is confusing and error-prone if the two diverge. Consider either removing the hash computation here or making the repository use the entity's hash.

phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)

123-160: Duplicated fetch-loop logic in getByUserId and getAll.

Both methods repeat the same while ($row) { ... $row = $db->fetchObject($result); } pattern. Consider extracting a private helper to reduce duplication.

tests/phpMyFAQ/Push/WebPushServiceTest.php (1)

38-62: Mock returns 'true'/'false' strings, but real Configuration::get() converts them to booleans.

Looking at Configuration::get() (from the relevant snippets), it uses a match to convert 'true'true and 'false'false. So in production, isEnabled() will receive a boolean, not a string. The isEnabled() method handles both (=== 'true' || === true), so these tests pass—but they exercise the string-comparison branch that won't occur in practice. Consider using boolean return values in the mock to better reflect real behavior.

phpmyfaq/assets/src/api/push.ts (2)

82-99: unsubscribePush reuses PushSubscribeResponse as return type.

The return type PushSubscribeResponse is semantically misleading for an unsubscribe operation. Consider introducing a shared PushActionResponse type (or similar) to avoid the naming confusion.


30-46: Unnecessary Content-Type: application/json header on GET requests.

Lines 34–36 (and similarly Lines 105–107 in getPushStatus) set a Content-Type header on GET requests that have no body. This is harmless but unnecessary.

phpmyfaq/assets/src/push/index.ts (2)

144-153: Consider reversing unsubscribe order: notify server before local unsubscribe.

If unsubscribePush(endpoint) fails after currentSubscription.unsubscribe() succeeds (line 147–148), the browser is unsubscribed but the server retains a stale record. While the server will eventually purge it on the next send attempt, reversing the order would let you abort cleanly on server failure without orphaning the local subscription.

♻️ Suggested reorder
       if (isSubscribed && currentSubscription) {
         const endpoint = currentSubscription.endpoint;
-        await currentSubscription.unsubscribe();
         await unsubscribePush(endpoint);
+        await currentSubscription.unsubscribe();
         isSubscribed = false;

229-235: Fragile DOM traversal: parentElement?.parentElement? is brittle.

Line 231 walks up two parent nodes to hide the UCP section. Any template change will silently break this. Prefer closest() with a known selector.

♻️ Suggested fix
       if (!vapidResponse.enabled || !vapidResponse.vapidPublicKey) {
         if (ucpButton) {
-          ucpButton.parentElement?.parentElement?.classList.add('d-none');
+          ucpButton.closest('.pmf-push-toggle-container')?.classList.add('d-none');
         }

Add the .pmf-push-toggle-container class (or a suitable selector) to the wrapping element in the template.

phpmyfaq/src/phpMyFAQ/Push/WebPushService.php (1)

114-124: VAPID subject is fetched from config twice; extract to a local variable.

$this->configuration->get('push.vapidSubject') is called on both lines 117 and 119. A local variable is cleaner and avoids the double lookup.

♻️ Suggested simplification
         $auth = [
             'VAPID' => [
-                'subject' =>
-                    $this->configuration->get('push.vapidSubject') !== ''
-                    && $this->configuration->get('push.vapidSubject') !== null
-                        ? $this->configuration->get('push.vapidSubject')
-                        : 'mailto:' . $this->configuration->getAdminEmail(),
+                'subject' => !empty($vapidSubject = $this->configuration->get('push.vapidSubject'))
+                    ? $vapidSubject
+                    : 'mailto:' . $this->configuration->getAdminEmail(),
                 'publicKey' => $this->configuration->get('push.vapidPublicKey'),
                 'privateKey' => $this->configuration->get('push.vapidPrivateKey'),
             ],
         ];

Or, for readability:

$vapidSubject = $this->configuration->get('push.vapidSubject');
$auth = [
    'VAPID' => [
        'subject' => !empty($vapidSubject) ? $vapidSubject : 'mailto:' . $this->configuration->getAdminEmail(),
        // ...
    ],
];

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PushController.php`:
- Around line 66-70: In the catch block inside PushController (the catch
(\Throwable $e) in phpMyFAQ/Controller/Administration/Api/PushController.php)
stop returning $e->getMessage() directly; instead log the full exception (use
the controller's logger or error_log() to record $e, including stack trace) and
return a generic API error payload like 'An internal error occurred' with the
same HTTP_INTERNAL_SERVER_ERROR status; ensure the logged entry includes
$e->getMessage() and $e->getTraceAsString() so developers can debug without
leaking internals to clients.

In `@phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php`:
- Around line 94-120: The unsubscribe action currently calls
PushSubscriptionRepository::deleteByEndpointHash allowing any authenticated user
to remove arbitrary subscriptions; add a new repository method
deleteByEndpointHashAndUserId(string $endpointHash, int $userId): bool in
PushSubscriptionRepository that performs the DELETE scoped by endpoint_hash AND
user_id, then update the Frontend\Api\PushController::unsubscribe to obtain the
current user id after userIsAuthenticated() (e.g. from the authenticated user
object/session) and call deleteByEndpointHashAndUserId($endpointHash, $userId)
instead of deleteByEndpointHash to enforce per-user authorization.

In `@phpmyfaq/src/phpMyFAQ/Notification.php`:
- Around line 310-317: Replace the hardcoded superadmin user id check in
Notification::(/* around adminUserIds usage */) with a lookup for users whose
is_superadmin flag is set: add or use a User class helper (e.g.
User::getSuperAdminIds() or User::getSuperAdmins()) that queries the users table
for is_superadmin = 1 and returns their IDs, then merge those IDs into
$adminUserIds instead of adding literal 1; also search for the same pattern in
UserController (the delete protection around user id 1) and replace the
hardcoded check with a call to the same helper to protect any user returned as a
superadmin.
🧹 Nitpick comments (7)
tests/phpMyFAQ/Setup/Migration/QueryBuilder/TableBuilderTest.php (1)

338-365: Formatting looks good; consider aligning the sibling test for consistency.

The multi-line chain style on lines 355–360 is an improvement for readability. However, the nearly identical chain in testSqliteAutoIncrementDoesNotDuplicatePrimaryKey (line 341) is still on a single line. Consider reformatting that one as well so the two related tests are visually consistent.

Also, the trailing comment on line 360 (// This should be ignored when autoIncrement is used) describes the behavior of ->primaryKey('id'), not ->build(). Moving it to a standalone line above the chain (or next to ->primaryKey('id')) would be clearer.

♻️ Suggested alignment with sibling test
     public function testSqliteAutoIncrementDoesNotDuplicatePrimaryKey(): void
     {
         $sqliteBuilder = new TableBuilder(new SqliteDialect());
-        $sql = $sqliteBuilder->table('test', false)->autoIncrement('id')->varchar('name', 100)->build();
+        $sql = $sqliteBuilder
+            ->table('test', false)
+            ->autoIncrement('id')
+            ->varchar('name', 100)
+            ->build();
     public function testAutoIncrementSkipsExplicitPrimaryKey(): void
     {
         $sqliteBuilder = new TableBuilder(new SqliteDialect());
+        // primaryKey('id') should be ignored when autoIncrement is used
         $sql = $sqliteBuilder
             ->table('test', false)
             ->autoIncrement('id')
             ->varchar('name', 100)
             ->primaryKey('id')
-            ->build(); // This should be ignored when autoIncrement is used
+            ->build();
phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/FaqController.php (1)

269-276: Duplicated FAQ URL construction pattern.

The sprintf pattern for building the public FAQ URL is identical to lines 202–208 (open question answered flow). Consider extracting a small helper to avoid maintaining the same URL template in two places within this method.

♻️ Suggested helper extraction
+    // Helper to build public FAQ URL (could also be a method on FaqEntity or a utility)
+    $buildFaqUrl = static fn (int $categoryId, FaqEntity $faq, string $baseUrl): string => sprintf(
+        '%scontent/%d/%d/%s/%s.html',
+        $baseUrl,
+        $categoryId,
+        $faq->getId(),
+        $faq->getLanguage(),
+        TitleSlugifier::slug($faq->getQuestion()),
+    );

Then replace both occurrences (lines 202–208 and 269–275) with:

$faqUrl = $buildFaqUrl($categories[0], $faqData, $this->configuration->getDefaultUrl());
phpmyfaq/admin/assets/src/configuration/webpush.ts (1)

86-86: Consider i18n for user-facing strings.

The notification messages ("VAPID keys have been generated successfully", "Failed to generate VAPID keys", "Generate VAPID Keys", "Generating...") are hardcoded in English. If the admin backend supports localization, these should use the translation system.

phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (1)

52-52: Authenticated endpoint route names use api.private.* instead of the frontend convention.

Per coding guidelines, frontend routes should follow public.{resource}.{action}. These three authenticated routes use the api.private.push.* pattern. Consider aligning them — e.g. public.push.subscribe, public.push.unsubscribe, public.push.status — or establishing a clear private.{resource}.{action} convention for authenticated frontend API routes. As per coding guidelines, "Frontend routes should follow the naming convention: public.{resource}.{action}".

Also applies to: 94-94, 125-125

phpmyfaq/src/phpMyFAQ/Push/WebPushService.php (1)

125-174: sendToSubscriptions is well-structured overall.

Good use of the try/catch around the entire notification flow, expired-endpoint cleanup via flush reports, and the fallback to getAdminEmail() for the VAPID subject. A couple of minor observations:

  • The push.vapidSubject config value is fetched three times (Lines 135, 136, 137). Assigning it to a local variable would be cleaner and slightly more efficient.
  • Notification sending is synchronous; for large subscriber counts, consider documenting that this should be dispatched to a background job. Not blocking for now, but worth noting for future scale.
♻️ Minor: cache the config lookup
+        $vapidSubject = $this->configuration->get('push.vapidSubject');
         $auth = [
             'VAPID' => [
                 'subject' =>
-                    $this->configuration->get('push.vapidSubject') !== ''
-                    && $this->configuration->get('push.vapidSubject') !== null
-                        ? $this->configuration->get('push.vapidSubject')
+                    !empty($vapidSubject)
+                        ? $vapidSubject
                         : 'mailto:' . $this->configuration->getAdminEmail(),
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (2)

40-89: TOCTOU race in the upsert: concurrent requests for the same endpoint can cause a duplicate-key failure.

The save() method performs a SELECT to check existence, then either UPDATEs or INSERTs. Between these two queries, a concurrent request with the same endpoint could insert a row, causing the second INSERT to fail (assuming a unique index on endpoint_hash). For push subscriptions the practical risk is low, but be aware this can surface under load.

A database-level INSERT … ON DUPLICATE KEY UPDATE (MySQL) or INSERT … ON CONFLICT (PostgreSQL) would be the robust fix, though the multi-DB abstraction layer may not support it. At minimum, consider wrapping the check-and-write in a transaction.


127-146: Duplicated fetch-loop pattern across three methods.

getByUserId, getByUserIds, and getAll share identical row-fetching logic (query → check false → while-loop → mapRowToEntity). Consider extracting a private helper like fetchSubscriptions(string $query): array.

♻️ Proposed helper extraction
+    /**
+     * `@return` PushSubscriptionEntity[]
+     */
+    private function fetchSubscriptions(string $query): array
+    {
+        $db = $this->configuration->getDb();
+        $result = $db->query($query);
+        if ($result === false) {
+            return [];
+        }
+
+        $subscriptions = [];
+        $row = $db->fetchObject($result);
+        while ($row) {
+            $subscriptions[] = $this->mapRowToEntity($row);
+            $row = $db->fetchObject($result);
+        }
+
+        return $subscriptions;
+    }

Then each method simplifies to building the query string and calling return $this->fetchSubscriptions($query);.

Also applies to: 154-178, 185-204

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 239-253: In mapRowToEntity, constructing DateTimeImmutable from
$row->created_at can throw on malformed data; wrap the parse in a defensive
block: either use DateTimeImmutable::createFromFormat with the expected DB
datetime format and verify the result, or wrap new
DateTimeImmutable($row->created_at) in a try/catch that catches \Exception and
provides a safe fallback (e.g. null or a default DateTimeImmutable) before
calling $entity->setCreatedAt, and optionally log the parse error so corrupted
created_at values don't raise unhandled exceptions.
🧹 Nitpick comments (7)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (3)

40-89: save() recomputes endpointHash but ignores the value already set on the entity.

Line 43 computes $endpointHash = hash('sha256', $entity->getEndpoint()), which is correct for the DB operation. However, this means the entity's own endpointHash property (set by the caller via setEndpointHash()) is silently ignored. If a caller sets a different hash on the entity, it will be overwritten in the DB without any indication. Consider either (a) always using $entity->getEndpointHash() (trusting the caller), or (b) setting the computed hash back on the entity so it stays consistent after save.

Option (b) — keep entity in sync
     $endpointHash = hash('sha256', $entity->getEndpoint());
+    $entity->setEndpointHash($endpointHash);

202-221: getAll() fetches every subscription without a LIMIT.

If the subscriber base grows large, this will load all rows into memory in a single query. This is used by WebPushService to send notifications, so it could become a bottleneck. Consider adding pagination or a batched iterator, or at minimum a configurable upper bound.


144-163: Duplicated fetch-loop pattern across three methods.

getByUserId, getByUserIds, and getAll each repeat the identical while ($row) { … fetchObject } loop. A small private helper would reduce duplication.

♻️ Extract helper
+    /**
+     * Fetches all rows from a result set and maps them to entities.
+     *
+     * `@return` PushSubscriptionEntity[]
+     */
+    private function fetchAllEntities($result): array
+    {
+        $db = $this->configuration->getDb();
+        $subscriptions = [];
+        $row = $db->fetchObject($result);
+        while ($row) {
+            $subscriptions[] = $this->mapRowToEntity($row);
+            $row = $db->fetchObject($result);
+        }
+        return $subscriptions;
+    }

Then each method simplifies to:

return $this->fetchAllEntities($result);

Also applies to: 171-195, 202-221

phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php (3)

37-47: VAPID public key endpoint returns key even when push is disabled.

When $webPushService->isEnabled() is false, the endpoint still returns the VAPID public key (which may be empty or a stale value). Consider returning null for the key when push is disabled to avoid leaking configuration details unnecessarily and to give the client a clearer signal.

♻️ Suggested tweak
         return $this->json([
             'enabled' => $webPushService->isEnabled(),
-            'vapidPublicKey' => $webPushService->getVapidPublicKey(),
+            'vapidPublicKey' => $webPushService->isEnabled() ? $webPushService->getVapidPublicKey() : null,
         ], Response::HTTP_OK);

63-66: FILTER_SANITIZE_SPECIAL_CHARS may mangle Web Push key material.

FILTER_SANITIZE_SPECIAL_CHARS HTML-encodes characters like &, ', ", <, >. While base64url-encoded keys ([A-Za-z0-9_-=]) shouldn't typically contain these, using an HTML-oriented sanitizer on cryptographic key data is semantically wrong and fragile. A validation filter (e.g., regex allowlist for base64url characters) would be more appropriate than a sanitizer that silently transforms input.


37-37: Route naming uses api.public.* / api.private.* — deviates from conventions.

The coding guidelines state frontend routes should follow public.{resource}.{action}, and learnings indicate API routes should follow api.{resource}.{action}. The current names (api.public.push.vapid-public-key, api.private.push.subscribe, etc.) blend both patterns with an extra public/private qualifier. If this is an intentional convention for distinguishing authenticated vs. unauthenticated API endpoints in this project, it should be documented. Otherwise, align with one convention.

As per coding guidelines, "Frontend routes should follow the naming convention: public.{resource}.{action}". Based on learnings, "API routes should follow the naming convention: api.{resource}.{action}".

Also applies to: 52-52, 94-94, 126-126

phpmyfaq/src/phpMyFAQ/Notification.php (1)

307-324: Method exists and is properly typed — optional guard remains valid.

User::getSuperAdminIds() at line 970 in User.php is correctly defined as public static function getSuperAdminIds(Configuration $configuration): array and properly returns int[]. The usage at line 315 is correct.

The optional guard against empty $adminUserIds remains a reasonable optimization. If both no category owner ($userId <= 0) and no superadmins are found, sendToUsers() is called with an empty array, which may result in unnecessary processing. Consider adding:

if (empty($adminUserIds)) {
    return;
}

This prevents calling the web push service when there are no recipients.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 40-89: The save method in PushSubscriptionRepository uses a
non-atomic SELECT→UPDATE/INSERT sequence leading to race conditions; change it
to an atomic upsert using the table's UNIQUE(endpoint_hash) constraint: replace
the SELECT + conditional UPDATE/INSERT in save(PushSubscriptionEntity $entity)
with a single atomic statement (e.g. INSERT ... ON DUPLICATE KEY UPDATE or
equivalent for the DB abstraction) that updates user_id, endpoint, public_key,
auth_token, content_encoding, and created_at when endpoint_hash already exists;
if the DB abstraction does not support that SQL, catch the
duplicate-key/constraint violation from the INSERT into $this->table (using the
computed $endpointHash) and then perform a safe UPDATE of the existing row to
avoid returning false on a benign race. Ensure you reference and update the same
fields currently used in $updateQuery and $insertQuery.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)

154-160: Consider extracting the repeated fetch loop into a private helper.

The fetch-object-while-loop pattern is duplicated across getByUserId, getByUserIds, and getAll. A small private method would reduce repetition and centralize the mapping logic.

♻️ Suggested helper extraction
+    /**
+     * `@return` PushSubscriptionEntity[]
+     */
+    private function fetchAll($result): array
+    {
+        $subscriptions = [];
+        $row = $this->configuration->getDb()->fetchObject($result);
+        while ($row) {
+            $subscriptions[] = $this->mapRowToEntity($row);
+            $row = $this->configuration->getDb()->fetchObject($result);
+        }
+        return $subscriptions;
+    }

Each caller then becomes:

-        $subscriptions = [];
-
-        $row = $db->fetchObject($result);
-        while ($row) {
-            $subscriptions[] = $this->mapRowToEntity($row);
-            $row = $db->fetchObject($result);
-        }
-
-        return $subscriptions;
+        return $this->fetchAll($result);

Also applies to: 186-192, 212-218

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php`:
- Around line 65-72: The try/catch in PushSubscriptionRepository around the
$db->query($insertQuery) currently catches the imported phpMyFAQ\Core\Exception,
so native DB driver errors escape; replace the catch(Exception) with a catch of
the global error type (e.g., catch(\Throwable $e) or catch(\Exception $e)) so
database constraint violations are intercepted and execution can fall through to
the UPDATE branch, and ensure the change doesn't conflict with any existing
Exception import.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Push/PushSubscriptionRepository.php (1)

157-165: Consider extracting the fetch loop into a private helper.

The fetch-and-map loop is repeated verbatim in getByUserId, getByUserIds, and getAll. A small helper would reduce duplication:

Example helper
private function fetchAll($result): array
{
    $subscriptions = [];
    $row = $this->configuration->getDb()->fetchObject($result);
    while ($row) {
        $subscriptions[] = $this->mapRowToEntity($row);
        $row = $this->configuration->getDb()->fetchObject($result);
    }
    return $subscriptions;
}

Each caller then becomes:

         $result = $db->query($query);
         if ($result === false) {
             return [];
         }
-        $subscriptions = [];
-        $row = $db->fetchObject($result);
-        while ($row) {
-            $subscriptions[] = $this->mapRowToEntity($row);
-            $row = $db->fetchObject($result);
-        }
-        return $subscriptions;
+        return $this->fetchAll($result);

Also applies to: 189-197, 215-223

@thorsten thorsten merged commit d50b57b into main Feb 6, 2026
10 checks passed
@thorsten thorsten deleted the feat/web-push-api branch February 6, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant